iT邦幫忙

2025 iThome 鐵人賽

DAY 25
1

https://ithelp.ithome.com.tw/upload/images/20251009/20168201UCUBQOnEej.png

前言

前面幾篇文章介紹了一些 FP 世界中的容器工具如 Functor、Monad、Applicative 等,其實還有很多沒有介紹到,例如 Reader、State 等容器,轉換容器的自然轉換(natural transformations),和重新排列型別順序的 Traversable 等,不過因為篇幅關係,這裡想先告一段落,統整目前介紹的 FP 工具以及他們和 Monoid 的關聯,最後會發現從函式到運算流程,都能以 Monoid 的方式被組合起來。

其他進一步的 FP 容器和轉換方法就有興趣的人再去了解看看囉!

一點點範疇論

很簡單的提及一點點範疇論,範疇論(Category Theory)常被視為一門高度抽象的數學分支,但對於程式設計而言,可以將其理解為「關於組合的數學」。它關心的不是事物的內部細節,而是事物之間如何關聯與組合。

在程式設計的語境下,範疇論的核心概念可以這樣對應理解:

  • 物件(Objects): 可以簡單地視為程式語言中的型別(types)。例如 string、number、boolean,或是我們在 TypeScript 自訂的 UserOrder 等型別,都是範疇中的物件。
  • 態射(Morphisms / Arrows): 可以視為從一個型別對應到另一個型別的純函數(pure functions)。例如,一個函式 (s: string) => s.length 就是一個從 string 型別到 number 型別的態射。
  • 組合(Composition): 即是函式的組合。給定兩個函式 f: A -> Bg: B -> C,我們可以將它們組合成一個新的函式 h: A -> C,其定義為 h(x) = g(f(x))。範疇論的一個基本要求是,這種組合必須滿足結合律(associativity),即 (h • g) • fh • (g • f) 是等價的。
  • 恆等(Identity): 對於範疇中的任何一個物件(型別)A,都存在一個恆等態射(identity morphism),即一個函式 id: A -> A,它不做任何事,僅僅傳回輸入值。例如 const id = (x) => x。它在函式組合中的作用類似於數字 0 在加法中的角色。

在程式設計世界裡,範疇論為我們提供了一套語言,讓我們能精確地描述這些關於「組合」的模式,無論組合的是數值還是整個運算流程。

從 Semigroup 開始

基礎代數結構(Fundamental Algebraic Structures)

在 FP 世界裡,我們會參考數學的抽象代數結構來讓程式結構更穩定、如數學般可預測。這些代數結構透過定義集合及其上的運算規則,提供了一套強大的抽象工具。以下是幾個比較常被提及的代數結構:

Magma

Magma 是最基礎的代數結構。它由一個非空集合 S 和一個定義在 S 上的二元運算 組成。

  • 封閉性 (Closure):對於集合 S 中任意兩個元素 ab,其運算結果 a•b 也必須在 S 中。

簡言之,只要有一組東西,以及一種能將其中任意兩個東西組合起來,並產生出同一組東西的運算,就構成了一個 Magma。這確保了運算不會產生集合之外的結果。

Semigroup

Semigroup 在 Magma 的基礎上,增加了一個結合律 (Associativity) 的要求。

  • 集合 S 與二元運算 :同 Magma。
  • 封閉性 (Closure):同 Magma。
  • 結合律 (Associativity):對於集合 S 中任意三個元素 abc,運算的順序不影響結果,即 (a•b)•c=a•(b•c)

Semigroup 允許我們安全地「串聯」多個運算,因為無論我們如何分組,最終的結果都會相同。

Monoid

Monoid 在 Semigroup 的基礎上,再增加了一個單位元素 (Identity Element) 的要求。

  • 集合 S 與二元運算 :同 Semigroup。
  • 封閉性 (Closure):同 Semigroup。
  • 結合律 (Associativity):同 Semigroup。
  • 單位元素 (Identity Element):在集合 S 中存在一個特殊元素 e,對於 S 中任意元素 a,都有 a•e=ae•a=a

單位元素就像一個「無作用」的元素,它在運算中不會改變其他元素的值。

Group

Group 在 Monoid 的基礎上,再增加了一個逆元素 (Inverse Element) 的要求。

  • 集合 S 與二元運算 :同 Monoid。
  • 封閉性 (Closure):同 Monoid。
  • 結合律 (Associativity):同 Monoid。
  • 單位元素 (Identity Element):同 Monoid。
  • 逆元素 (Inverse Element):對於集合 S 中任意元素 a,都存在一個元素 $a^{-1}$,使得 $a•a^{-1}=e$ 且 $a^{-1}•a=e$ (其中 e 是單位元素)。

https://ithelp.ithome.com.tw/upload/images/20251009/201682011aT7x2BYqI.png
圖 1 逆元素示意圖(因為 iThome 無法表示 LaTeX 數學符號,只好用圖表示)(資料來源: 自行繪製)

逆元素的概念可想成是「可撤銷的操作」。對於元素 a,存在一個元素 $a^{-1}$,使得 $a•a^{-1}$ 的結果等於單位元素 $e$。
整數與加法操作就是一個 Group,對於任何整數 n,它的逆元素是 -n,因為 n + (-n) = 0(加法的單位元素)。
然而,我們常用的字串串接就不是 Group。你可以將 "Hello"" World" 串接起來,但你無法輕易地「反向串接」或「撤銷」這個操作。


簡單小結這些代數結構的遞進關係:

  • Magma = 封閉性 + 一個二元運算
  • Semigroup = Magma + 結合律 (能組合,但處理不了空集合)
  • Monoid = Semigroup + 單位元素 (能安全地組合,包括空集合)
  • Group = Monoid + 逆元素 (能組合,也能撤銷)

https://ithelp.ithome.com.tw/upload/images/20251009/20168201bt0Xblf8ns.png
圖 2 Magma、Semigroup、Monoid 與 Group 的關係示意圖(資料來源: 自行繪製)

認識 Semigroup

首先從 Semigroup 來理解,一個 Semigroup 由兩部分構成:一個集合(在程式中對應為一個型別 A),以及一個作用於該集合的二元運算(binary operation),這個運算通常被命名為 concat,其型別簽章為 concat: (A, A) -> A。這代表該運算接收兩個型別為 A 的值,經過組合後,回傳一個同樣型別為 A 的值。這個特性被稱為封閉性(closure)。

另外,這個二元運算還需滿足結合律,也就是對於任何 abc(a.concat(b)).concat(c) 的結果必須與 a.concat(b.concat(c)) 完全相同。

關於封閉性與結合律對於程式設計的意義,已在「初探 Monoid」介紹過,這裡不再贅述。

JavaScript 實作例子

用 JavaScript 來為加法實作一個 Semigroup Sum 看看:

const Sum = x => ({
  x,
  concat: other => Sum(x + other.x)
});

與另一個 Sum 進行 concat,永遠會回傳一個新的 Sum

Sum(1).concat(Sum(3)) // Sum(4)
Sum(4).concat(Sum(37)) // Sum(41)

不過要補充的是,Sum 不是「pointed functor」,因為 Sum 不能 map,Sum 只能處理 number -> number,無法轉為其他型別,且 number 並不是一個包著另一個值的容器。

再看看其他型別的 concat 方式:

// 數值相關的
const Product = x => ({ x, concat: o => Product(x * o.x) });
const Min = x => ({ x, concat: o => Min(x < o.x ? x : o.x) });
const Max = x => ({ x, concat: o => Max(x > o.x ? x : o.x) });

// 布林值
const Any = x => ({ x, concat: o => Any(x || o.x) });
const All = x => ({ x, concat: o => All(x && o.x) });

// 使用方式
Any(false).concat(Any(true)) // Any(true)
Any(false).concat(Any(false)) // Any(false)

All(false).concat(All(true)) // All(false)
All(true).concat(All(true)) // All(true)

[1,2].concat([3,4]) // [1,2,3,4]

"hello monoi".concat("d") // "hello monoid"

也可以自己寫一個簡單的 Map 的 Semigroup,並實作 concat 方法來合併物件內容,這裡的 concat 邏輯先簡單寫(相同的 key 會後蓋前),如果要每個 key 內的值都能合併,邏輯會再稍微複雜點,有興趣的可再自己實作看看。

const Map = obj => ({
  obj,
  concat: other => Map({ ...obj, ...other.obj }),
});

// 使用方式
Map({day: 'night'}).concat(Map({white: 'nikes'})) // Map({day: 'night', white: 'nikes'})

為什麼有用?

為何我們要為這些數值、字串或布林值定義 concat 方法呢? 用 semigroup 來思考程式有什麼用?

定義這些型別的基本元素和 concat 方法,讓我們可以統一介面,雖然他們是不同型別(數字、字串、陣列、布林、物件…),但我們都能用「組合」的思維來統一處理,而「組合」的思維有助於我們處理一些程式世界常見的模式:

  • 合併資料結構
  • 邏輯組合
  • 累積運算

用抽象的數學來思考程式,讓我們得以對介面程式設計(program to an interface),並以定律作為正確性的保證。

Functors are semigroups

當容器裡的值本身是 semigroup,就能推導出整個容器也是 semigroup。

我們過去介紹的各種 Functor 如 Maybe、Either、IO 等,其實同時也能實作 Semigroup。

https://ithelp.ithome.com.tw/upload/images/20251009/20168201fS1jY1yEwp.png
圖 3 當容器裡的值本身是 semigroup,就能推導出整個容器也是 semigroup(資料來源: 自行繪製)

舉例來說,我們可以定義 Identity 這個 Functor 的 concat 方法(Identity 就是以前所稱的 Container)。

Identity.prototype.concat = function(other) {
  return new Identity(this.__value.concat(other.__value))
}

// concat 的行為:把兩個 Identity 的值合併
Identity.of(Sum(2)).concat(Identity.of(Sum(3))) // Identity(Sum(5))
Identity.of(4).concat(Identity.of(1)) // TypeError: this.__value.concat is not a function

從上範例可看出,Identity 這容器是否是 semigroup、是否可順利執行 concat,取決於 __value 是否是 semigroup。
因為 Sum(2) 有定義 .concat,所以能運作(Sum 是 semigroup),而 4 (原始數字) 沒有 .concat 方法,因此會噴錯(4 是原始數字不是 semigroup)。

由此可知,容器是否是 semigroup,取決於內部值是否是 semigroup

其他 Functor 的 Semigroup 行為

Either (Right / Left)

Right(Sum(1)).concat(Right(Sum(4))) // Right(Sum(5))
Right(Sum(2)).concat(Left('some error')) // Left('some error')

如果組合時遇到 Right,那就正常合併,因此 Right(Sum(1)).concat(Right(Sum(4))) 的結果是 Right(Sum(5))
如果遇到 Left,就直接保留錯誤,不會繼續合併。

Task (非同步)

Task.of([1,2]).concat(Task.of([3,4])) // Task([1,2,3,4])

Task 代表非同步結果,合併時會把結果(陣列)串接起來。在定義 Task 的 concat 方法時,我們可用 semigroup 規則安全合併。

疊加起來後組合

利用 semigroup 的規則,我們可以疊加不同容器,再進行組合。

// --- 底層 Semigroup:Map(其實就是物件) 與 Array ---
const SMapRHS = { concat: (a, b) => ({ ...a, ...b }) };    // 右邊覆蓋
const SArray  = { concat: (a, b) => a.concat(b) };

// --- Maybe ---
const Just = (x) => ({ _tag: 'Just', value: x });
const Nothing = { _tag: 'Nothing' };
const isJust = (m) => m._tag === 'Just';
const SMaybe = (S) => ({
  concat: (ma, mb) =>
    isJust(ma) && isJust(mb) ? Just(S.concat(ma.value, mb.value))
    : isJust(ma) ? ma
    : isJust(mb) ? mb
    : Nothing
});

// --- IO:包住 thunk 的容器 ---
const IO = (thunk) => ({
  run: thunk,
  map: (f) => IO(() => f(thunk())),
});
const SIO = (S) => ({
  concat: (ia, ib) => IO(() => S.concat(ia.run(), ib.run()))
});

// --- Task:這裡用 Promise 當 Task ---
const Task = (thunk) => ({
  run: () => thunk(),
  map: (f) => Task(() => Promise.resolve(thunk()).then(f)),
});
const STask = (S) => ({
  concat: (ta, tb) => Task(async () => S.concat(await ta.run(), await tb.run()))
});

// -------------------- 範例開始 --------------------

// 範例 1:IO(Either(Map)):讀表單資料 → 驗證
// 「先驗證,再合併」,Left 直接傳遞
const Right = (x) => ({ _tag: 'Right', right: x });
const Left  = (e) => ({ _tag: 'Left', left: e });
const isRight = (e) => e._tag === 'Right';
const SEither = (S) => ({
  concat: (ea, eb) =>
    isRight(ea) && isRight(eb)
      ? Right(S.concat(ea.right, eb.right)) // 兩個 Right 才合併
      : isRight(ea)
        ? eb  // 左邊 Right、右邊 Left -> 回 Left(短路)
        : ea  // 左邊 Left -> 回 Left(短路)
});

// formValues :: Selector -> IO(Map)
const formValues = (sel) =>
  IO(() => (sel === '#signup'
    ? { username: 'andre3000' }
    : sel === '#terms'
    ? { accepted: true }
    : {}));

// validate :: Map -> Either Error Map
const validate = (m) =>
  m.accepted === false ? Left('must accept terms') : Right(m);

// IO(Either(Map)) 的 semigroup:由內部 Map 的合併規則一路提升
const SIOEitherMap = SIO(SEither(SMapRHS));

// OK:兩個表單都驗證成功 → 內部 Map 合併
const ok = SIOEitherMap.concat(
  formValues('#signup').map(validate),
  formValues('#terms').map(validate)
).run();
// => Right({ username:'andre3000', accepted:true })

// BAD:其中一邊變成 Left → Left 直通
const bad = SIOEitherMap.concat(
  formValues('#signup').map(validate),
  IO(() => Left('one must accept our totalitarian agreement'))
).run();
// => Left('one must accept our totalitarian agreement')

console.log(ok, bad);

// 範例 2:Task(Array):兩個非同步請求並行 → 結果用陣列 semigroup 合併
const serverA = { get: () => Task(() => Promise.resolve(['friend1'])) };
const serverB = { get: () => Task(() => Promise.resolve(['friend2'])) };

const STaskArray = STask(SArray);
STaskArray.concat(serverA.get('/friends'), serverB.get('/friends'))
  .run().then(console.log);
// => ['friend1','friend2']

// 範例 3:Task(Maybe(Map)):載入兩組設定(可能有缺)
//(有的才合、沒有就跳過)
const loadSetting = (key) => Task(async () => {
  if (key === 'email')   return Just({ backgroundColor: true });
  if (key === 'general') return Just({ autoSave: false });
  return Nothing;
});
const STaskMaybeMap = STask(SMaybe(SMapRHS));

STaskMaybeMap.concat(loadSetting('email'), loadSetting('general'))
  .run().then(console.log);
// => Just({ backgroundColor:true, autoSave:false })

以上程式有三個組合的範例:

  1. 範例 1 中,IO(Either(Map)) 會讀取表單資料,再驗證,然後合併結果,如果成功就順利組合,失敗就短路回傳錯誤訊息
  2. 範例 2 中,Task(Array) 會從兩個伺服器抓朋友清單,再合併成一個結果
  3. 範例 3 中,Task(Maybe(Map)) 會載入多個設定檔並合併

雖然這些也可以用 chainap 來組合,但用 semigroup 來思考會更簡潔。

由此可知,不同層的容器可以疊加,因為底層值本身都是 semigroup

一切由 Semigroup 組成,就仍是 Semigroup

如果一個資料結構的每個欄位本身都是 semigroup,那整個資料結構也自然構成 semigroup。如果我們能 concat 零件,就能 concat 整體。

假設有個 UserStats 資料結構,裡面有 posts 貼文數量、tags 使用過的標籤和 longestSession 最長連續使用時間這三個欄位,且這三個欄位各自都是 semigroup,那 UserStats 本身也成為一個 semigroup,UserStats 本身也可以 concat

// 底層 semigroups
const Sum = (x) => ({
  x,
  concat: (other) => Sum(x + other.x),
  toString: () => `Sum(${x})`
});

const Max = (x) => ({
  x,
  concat: (other) => Max(Math.max(x, other.x)),
  toString: () => `Max(${x})`
});

// UserStats 資料結構
const UserStats = (posts, tags, longestSession) => ({
  posts,          // Sum
  tags,           // Array
  longestSession, // Max
  concat: (other) =>
    UserStats(
      posts.concat(other.posts),
      tags.concat(other.tags),
      longestSession.concat(other.longestSession)
    )
});

// 測試
const stats1 = UserStats(Sum(5), ['fp', 'monoid'], Max(120));
const stats2 = UserStats(Sum(3), ['functor'], Max(90));

const combined = stats1.concat(stats2);

console.log(combined);
// UserStats { posts: Sum(8), tags: ['fp','monoid','functor'], longestSession: Max(120) }

不只合併內容,也能合併容器

假設我們定義一個事件流的型別 Stream,這個事件流可以想像成一個持續不斷、隨時間發生變化的資料流,其中的每一筆資料都代表一個發生的事件,例如使用者點擊、IoT 裝置回報的感測器讀數、金融交易紀錄等。

在前端應用中,常見的事件流處理函式庫例如 RxJS,RxJS 會在之後介紹,這裡想說明的是,事件流 Stream 也是可以透過 concat 聚合的。而 RxJS 組合事件流的概念也與此有關。

const $ = (sel) => document.querySelector(sel);

// 來源:click 與 Enter
const submitStream = Stream.fromEvent('click', $('#submit'));
const enterStream  = Stream.fromEvent('keydown', $('#myForm'))
  .filter(e => e.key === 'Enter');

// 合併來源 → 統一處理
const submitFlow =
  submitStream
    .concat(enterStream)      // ⬅️ 合併事件流
    .map(e => {
      e.preventDefault();     // 避免表單跳頁
      return $('#username').value; // 當下 input 的值
    })
    .map(submitForm);         // 提交表單的處理邏輯

我們定義了 click 事件觸發的事件流 submitStream,以及 enter 按鍵觸發的事件流 enterStream,然後透過 concat 將事件流合併,這樣不論是 click 觸發的事件還是 enter 觸發的事件,都會匯聚在一起,並執行提交表單的處理邏輯。

這裡聚焦在容器型別 Stream 的 concat 範例,如何 subscribe 事件流來讓整體可運作,就先不細部解說,會在 RxJS 篇章再來說明~不過還是補上可運行的程式範例連結,有興趣的可參考看看。

從上面範例可看出,我們可以把事件串流合併成新的串流,不過合併的前提是,事件串流的內部值也要是 semigroup,這樣才能順利合併內容。

可自己選擇合併方式

每個型別可以定義不同的合併方式,舉例來說,Task 的合併方式可以是:

  1. 保留第一個結果
  2. 保留最後一個結果
  3. 忽略錯誤、只取第一個成功結果 (first Right)

具體的合併方式,就依照實際應用的需求來決定。如果想更了解如何選擇合併方式,可再看看 Alternative interface,它實作了一些方案,重點聚焦於「選擇」而不是「層疊組合」。(附上 fp-ts 的Alternative 型別連結)

Semigroup 加上熟悉的單位元素:Monoid

雖然 Semigroup 提供了組合的方式,但在某些情況下,它還有些不足,假設有個聚合操作,例如加總一個數字列表。如果列表是 [1, 2, 3],我們可以輕易地使用 Semigroup Sum 來得到 6。但如果列表是空的 [] 呢?Semigroup 並沒有告訴我們如何處理這種「無物可合」的情況。這正是 Monoid 登場的時機,也是我們熟悉的單位元素出場的時候。

在「初探 Monoid」的文章中已經介紹過什麼是 Monoid,不過了解 Semigroup 後,再看一次 Monoid 定義會更了解其意義:

一個 Monoid 就是一個帶有「單位元素 (Identity Element)」的 Semigroup。

這個單位元素通常稱為 empty,它必須遵守兩條定律:

  • 左單位律 (Left Identity): empty.concat(a) 的結果必須等於 a
  • 右單位律 (Right Identity): a.concat(empty) 的結果必須等於 a

簡單來說,單位元素就是一個在組合操作中「什麼都不做」的值。它提供了一個安全的起始點。

Array.empty = () => []
String.empty = () => ""
Sum.empty = () => Sum(0)
Product.empty = () => Product(1)
Min.empty = () => Min(Infinity)
Max.empty = () => Max(-Infinity)
All.empty = () => All(true)
Any.empty = () => Any(false)

單位元素對程式設計的意義已經在初探 Monoid說明過,這裡不再重複。

fold:有預設值的 reduce

初探 Monoid 文章有提到,reduce 方法可說是 Monoid 模式的完美體現,二元運算函數對應到 concat,初始值則對應到 empty。而如果我們沒有給 reduce 初始值 initialValue 的話,就會出現錯誤,由此也可看出單位元素的重要性。

為了讓 reduce 能更安全的被使用,可定義一個安全版的 reduce,強制一定要傳入初始值(即 Monoid 的 empty),這個安全版的 reduce 可命名為 fold

// reduce :: (b -> a -> b) -> b -> [a] -> b
// fold :: Monoid m => m -> [m] -> m
const fold = reduce(concat)

fold 接收一個 Monoid 的 empty 作為初始值(初始的 m),然後再壓縮一個 Monoid 陣列 [m] 得到最後的值,這樣做的好處是,即使陣列是空的,也能安全回傳單位元素。

fold 的使用範例

以下看一些 fold 的使用範例~

fold(Sum.empty(), [Sum(2), Sum(1)]) // Sum(3)
fold(Sum.empty(), []) // Sum(0)

fold(Any.empty(), [Any(false), Any(true)]) // Any(true)
fold(Any.empty(), []) // Any(false)

fold(Either.of(Max.empty()), [Right(Max(3)), Right(Max(21)), Right(Max(11))]) 
// Right(Max(21))

fold(Either.of(Max.empty()), [Right(Max(3)), Left('error retrieving value'), Right(Max(11))]) 
// Left('error retrieving value')

Either 的範例中,可看到如果是 reduce 所有 Right,最後會取最大值 Right(Max(21)),但如果中間有 Left,就會直接傳遞錯誤,不繼續合併。

不是所有 Semigroup 都能成為 Monoid

有些 Semigroup 無法定義一個合理的 empty 值,例如 First 這個型別:

const First = x => ({ 
  x, 
  concat: other => First(x) 
})

First(x) 的意思是保留第一個值,不管後面 concat 什麼,都回傳最初的那個。

實際應用的情境例如為一筆新資料定義 id,不管後面如何整合,都不該覆蓋或合併掉原先定義的 id 值。First 的整合範例如下。

Map({
  id: First(123), 
  isPaid: Any(true), 
  points: Sum(13)
}).concat(
  Map({
    id: First(2241), 
    isPaid: Any(false), 
    points: Sum(1)
  })
)
// 結果: Map({id: First(123), isPaid: Any(true), points: Sum(14)})

由上可知,對於 id 欄位,First(123)First(2241) 整合後只會得到第一個值 First(123)

First 是無法有 empty 元素的,可以想想看,如果要定義 First.empty(),應該回傳什麼?

這沒有合理答案,因為「第一個值」必須來自實際資料,不可能從空值開始。

並非所有 Semigroup 都能成為 Monoid,但這並不代表這種 Semigroup 沒有用,因為在實務上仍有應用情境,例如 First 可用在使用者註冊時的原始 ID 值,或用在處理設定檔的合併,當遇到多個 config 檔案時,First 代表「不論後面的設定是什麼,永遠取第一個載入的值」。

當「組合」本身成為 Monoid

目前為止,我們已經看到 Monoid 如何組合資料:數字相加 (Sum)、布林值判斷 (All)、甚至合併整個 UserStats 物件。我們也看到,只要容器內的值是 Monoid,整個容器 F<Monoid> 也能成為 Monoid。

我們討論了很多「組合」,那「組合」這個行為本身,是否也能形成一個 Monoid 呢?

可以,而這正是 Monoid 如此強大的原因,它不僅僅是關於資料的模式,更是關於運算與行為的模式。接下來會看到,FP 世界中核心的三個概念——函式組合、Monad 和 Applicative——其本質都可以用 Monoid 來詮釋。

函式組合的 Monoid (Composition as a Monoid)

函數式程式設計最基礎的運算就是函式組合,來看看為何 g(f(x)) 這樣的程式碼可滿足 Monoid 的定義。

  1. 一個型別:型別是 Endomorphism,Endomorphism 意思就是輸入和輸出型別相同的函式,其型別簽章為 a -> a。例如 (x: number) => x + 1
  2. 一個二元運算 (concat):運算就是 compose 函式。compose(g, f) 會回傳一個新的函式 x->g(f(x))。這個新函式依然是一個 a -> a 的 Endomorphism,滿足封閉性。
  3. 一個單位元素 (empty):單位元素是 id 函式,const id = x => x

可建立一個名為 Endo 的 monoid:

const Endo = run => ({
  run,
  concat: other =>
    Endo(compose(run, other.run))
})

Endo.empty = () => Endo(identity)
  • Endo:包裝一個函數 run
  • concat:透過 compose 來組合兩個函數(維持輸入輸出型別一致),因為它們都是相同的型別,所以可以用 composeconcat,且型別總是對得上(上一個的輸出型別與下一個需要的輸入型別相同)
  • Endo.empty():單位元就是恆等函數 (identity)

使用範例如下:

// thingDownFlipAndReverse :: Endo [String] -> [String]
const thingDownFlipAndReverse = fold(
  Endo(() => []),
  [Endo(reverse), Endo(sort), Endo(append('thing down'))]
)

thingDownFlipAndReverse.run(['let me work it', 'is it worth it?'])
// ['thing down', 'let me work it', 'is it worth it?']

這程式建立了一個 fold 函式,將多個 Endo 函數依序組合(compose):

  • reverse:反轉陣列
  • sort:排序陣列
  • append('thing down'):在陣列後面加上 'thing down'

並且設立初始值 Endo(() => []),若沒有元素,回傳空陣列,最後執行時,compose 會由右到左執行,先 append('thing down') 再排序,最後反轉,輸出新字串陣列。

驗證定律

  • 結合律:compose(h, compose(g, f)) 等於 compose(compose(h, g), f)。結合律成立。
  • 單位律:compose(f, id) 結果等於 f,compose(id, f) 結果等於 f。單位律成立。

滿足所有定律,因此可以將函式組合定義為一個 Monoid。

我們之所以能夠安心地建立函式處理管線 (pipeline),並隨意重構 h(g(f(x))) 這樣的程式碼,其背後的數學保證正是 Monoid 定律。

Monad 作為 Monoid (Monad as a Monoid)

「Monad is a Monoid in the Category of Endofunctors」這句話的解釋在之前 [Day 22] Monad 入門 (2):核心概念與定律 有提到過,今天更認識 Endofunctors 後,再來看看這是什麼意思,也許會有不同的理解。

我們可分兩種層次的理解方式,第一種從程式設計角度的「串接運算」出發,第二種則回歸更根本的範疇論定義。

視角一:組合帶有脈絡的運算 (Kleisli Arrows)

Monad 為那些「回傳 Monad 的函式」提供了一種符合 Monoid 定律的組合方式。

回想一下我們在 Monad 文章中學到的 chain (或 flatMap)。它的作用就是將一個 M(a) 和一個運算函式 a->M(b) 組合起來。這類 A → M(b) 形式的函式,代表著「接收一個普通值,回傳一個帶有上下文(如 MaybeTask)的值」,它們被稱為 Kleisli arrow。

Monad 的本質,就是定義如何組合這些 Kleisli arrows。

  1. 一個型別:型別是 Kleisli arrows,即 A->M(B) 形式的函式。
  2. 一個二元運算 (concat):一種特殊的 compose,稱為 kleisliCompose (在 Haskell 中是 >=>)。它接收兩個 Kleisli arrows,f:A->M(B)g:B->M(C),並將它們組合成一個新的 Kleisli arrow h:A->M(C)。這個組合的內部實現就是 chain
  3. 一個單位元素 (empty):單位元素是 Monad 的 of (或 pure, return) 函式,型別是 A->M(A)

驗證定律

Monad 的三條定律,正是 Monoid 定律在 Kleisli arrow 組合這個情境下的具體體現。

  • 結合律: kleisliCompose(h, kleisliCompose(g, f)) 等於 kleisliCompose(kleisliCompose(h, g), f)。對應到 Monad 的結合律意思就是 (m.chain(f)).chain(g) 等於 m.chain(x => f(x).chain(g))
  • 單位律: kleisliCompose(f, of)kleisliCompose(of, f) 都等於 f。這對應到 Monad 的左右單位律。

Monad 將 Monoid 這個強大的組合模式,應用於帶有上下文的運算流程中。當我們寫下 getUser(id).chain(getPostsByUser) 時,我們真正在做的不是在合併兩個 Task 值,而是在合併兩個「產生 Task 的動作」。

Monad 提供了一個符合 Monoid 定律的、安全可靠的方式來串聯這些動作。

視角二:組合容器本身的結構 (A Monoid in the Category of Endofunctors)

如果以範疇論視角來看 Monad,可看一下在 Endofunctors 這個特殊範疇內的元素:(一個 Endofunctor 指的是像 Maybe 或 Array 這樣,能將一個型別 A 包裹成 F(A),且兩者都存在於同一個型別系統中的 Functor)。

  • 物件 (Objects):是 Functor 本身,例如 Maybe、Task 這些型別建構子。
  • 態射 (Morphisms):是 Functor 之間的自然轉換(Natural Transformations),即一個 forall a. F(a) -> G(a) 的函式,它能在不改變內部值的結構下,轉換容器的型別。

來看看 Monoid 如何在這個「Endofunctors 範疇」中定義:

  • 一個物件 (A set):物件就是 Monad M 本身,它是一個將型別對應到自身的 Endofunctor。
  • 一個二元運算:操作是 join 。它是一個自然轉換,型別為 M(M(A)) → M(A)。它的作用是將兩層巢狀的 Monad 壓平成一層。
  • 一個單位元素:ofreturn。它也是一個自然轉換,型別為 A → M(A) (更精確地說是從 Identity functor 轉換)。它的作用是將一個普通值放入 Monad 這個「單位容器」中。

這兩種視角是完全等價的,因為 chain (或 bind) 和 join 可以互相定義:

  • m.chain(f) 等價於 join(map(f, m))
  • join(m) 等價於 m.chain(id)

Monad 的定律無論是用 chain 還是 join 來理解,最終都指向 Monoid 的結合律與單位律,因此可將 Monad 定義為一個 Monoid (並且是 Endofunctors 範疇中 Monoid)。

補充:Endomorphism 與 Endofunctors 差異

1. Endomorphism (自態射 / 自函式)

  • 層級:值的世界 (World of Values)
  • 定義:一個輸入型別與輸出型別完全相同的函式。
  • 簽章:A -> A
  • Endomorphism 描述的是「同型別的值之間的轉換」。它接收一個值,經過運算後,回傳一個相同型別的值。
  • 範例:const increment = (x: number): number => x + 1;
  • 恆等函式 id 也是一個典型的 Endomorphism。
  • 當我們討論函式組合的 Monoid 時,我們組合的正是這些 A -> A 的 Endomorphism。

2. Endofunctor (自函子)

  • 層級:型別的世界 (World of Types)
  • 定義:一個將一個範疇 (Category) 映射回自身的 Functor。
  • 概念:在程式設計中,這通常指一個容器或結構,它能接收任何型別 A,並將其包裹成一個新的型別 F<A>,而 F<A> 依然存在於我們原有的型別系統中。
  • Endofunctor 描述的是「將型別提升到一個結構中」的模式。
  • 範例:以 Maybe 來說,你給它 String 型別,它產生 Maybe<String> 型別。
特性 Endomorphism Endofunctor
操作對象 值 (Values) 型別 (Types)
本質 是一個函式 是一個結構/容器
簽章/模式 A -> A A => F<A>
簡單來說 值的同型別轉換 型別的結構化包裹
比喻 一台「將木頭加工成木椅」的機器 一份「為任何材料設計容器」的藍圖

Applicative 的 Monoid 特性 (Applicative as a Monoid)

Applicative Functor 之所以能夠「同時」處理多個獨立的計算、再把結果合併,其實背後的關鍵結構就是 Monoid。
這個特性在範疇論中被稱為 Lax Monoidal Functor —— 意即一種「能以 Monoid 方式結合的 Functor」。
我們可以從兩個角度來看這件事:

  1. Applicative 本身具有 Monoid 結構(在 Functor 層級上)
  2. 當內部的值是 Monoid 時,Applicative 可以「繼承」這個結構

Applicative 的 Monoidal 結構

Applicative 的核心在於它能將兩個獨立的容器「並行」地組合起來,變成一個新的容器。為了更清楚表達這個「並行結合」的概念,我們可以定義一個比 ap 更基礎的操作,稱為 product (或 zip)。

// product :: (F(A), F(B)) -> F((A, B))

這個操作接受兩個容器,並將它們組合成一個裝有 tuple 的新容器。例如以下:

product([1, 2], [3, 4])
// => [[1, 3], [1, 4], [2, 3], [2, 4]]

它做的事其實就像是「容器的 concat」,只不過是「同時」結合兩個容器裡的值。這裡就是對應到之前 [Day 24] Applicative Functor (2):定律與應用範例提到的笛卡兒積的概念。

這操作對應到 Monoid 的結構來看:

  • 一個型別: F(A),其中 F 是一個 Applicative。
  • 一個二元運算 (product): (F(A), F(B)) -> F((A, B))。此操作接收兩個容器,回傳一個新的容器,裡面裝著包含原先兩個值的元組 (tuple)。
  • 一個單位元素: of(()),一個包裹著「空元組」或「單位值」的容器。

product 操作就是 Applicative 的 monoidal concat。一旦有了 product,我們就可以用它和 map 來重新定義出 ap

// ap :: Functor f => (f (a -> b), f a) -> f b
const ap = (fab, fa) =>
  map(
    ([f, a]) => f(a),  // 對「裝著 pair」的容器做 map:把每個 pair 解構成 f 與 a,再呼叫 f(a),最後把結果留在原本的容器語境中
    product(fab, fa)   // 先把兩個容器並行結合,得到一個新容器,裡面放著 pair/tuple:[(函式), (值)]
  )

由此可看出 ap 的本質,ap 其實是由一個「Monoid 式的結合 (product)」再加上一個「函數式轉換 (map)」組成的。

這也就是為什麼 Applicative 能自然地組合多個獨立的運算,例如:將多個異步結果合併成單一結果,因為它本質上就是「Monoid 化」的結合邏輯。

當 Applicative 包裹著 Monoid

前面說的是 Applicative 本身具備 Monoid 結構。
接著我們要看另一種情況:

當一個 Applicative Functor 內部包裹的值本身就是 Monoid 時,整個結構 Applicative<Monoid> 也會成為一個 Monoid。

  1. Monoid 的 concat 提升到 Applicative
    假設我們有一個 Applicative 容器,像是 MaybePromiseTask,裡面包著 Monoid 值(例如字串或陣列):
Maybe("Hello ")
Maybe("World")

字串本身有 Monoid 結構,concat 為字串拼接,empty 為空字串 ""

那麼我們就可以用 liftA2 來「提升」字串的 concat,讓它能作用在 Applicative 上:

const concatA = liftA2((a, b) => a.concat(b))
concatA(Maybe("Hello "), Maybe("World"))
// => Maybe("Hello World")

liftA2 的作用就是把一個普通的二元函數(這裡是 Monoid 的 concat)提升成「Applicative-aware」的版本。也就是說,它會自動幫我們拆開兩個容器、取出裡面的值、套用函數、再包回容器中。以這裡來說,就是 liftA2 拆開了兩個 Maybe 容器,取出裡面的字串,套用字串的 concat 函數,再把結果包回 Maybe 容器中。

  1. Monoid 的 empty 也能被提升
    單位元素同樣可以被提升進 Applicative 的上下文中:
F.of(M.empty)

舉例來說:

Maybe.of("")
Promise.resolve([])
Task.of(0)

這樣 Applicative<Monoid> 整體就成為一個新的 Monoid:

  • concat: 透過 liftA2 來結合內部值,liftA2 接收一個普通的二元函式(在此就是內部 Monoid 的 concat),並用它來合併兩個 Applicative 容器內的值。
  • empty: 單位元素就是內部 Monoid 的 empty 值,被 of 方法提升到 Applicative 的上下文中:F.of(M.empty)

小結

我們從 Semigroup 出發,一路深入到 Monoid 在函式組合、Monad 和 Applicative 中的應用,最後發現 Monoid 將一切都串連起來了。

以下是本文的重點摘要:

  • Monoid 是關於組合的通用藍圖:它由一個型別、一個滿足結合律的 concat 操作和一個 empty 單位元素組成,為組合提供了數學上的保證。
    • 不只組合資料,更能組合運算:Monoid 的威力不僅在於合併數字或字串,更在於它能用來組合更高層次的抽象,如函式、異步操作和計算流程。
  • 函式組合本身就是一個 Monoid:composeconcatid 函式是 empty。我們使用的函式組合,其穩定性正源於 Monoid 定律。
  • Monad 是 monadic 函式的 Monoid:Monad 的 chainof 遵循 Monoid 定律,為帶有上下文(如錯誤處理、異步)的運算提供了可靠的串聯方式。從更根本的角度看,Monad 本身就是 Endofunctor 範疇中的一個 Monoid,其中 join 是組合操作,of 是單位元素。
  • Applicative 內含 Monoid 結構:Applicative 的 ap 操作可以從一個更根本的 product 操作(將兩個容器合併為一個內含元組的容器)推導而來,這揭示了其內在的 Monoid 特性。

最終我們發現,從 Functor、Applicative 到 Monad,這些看似獨立的概念,最終都可透過 Monoid 的視角被統一和理解。它顯示了函數式程式設計的核心——萬物皆可組合。

其他補充

一開始讀 FP 相關文章的時候我會覺得這些數學理論、結構很令人頭痛,其實現在也不能說完全理解這些數學,但我覺得以程式設計的角度來看,FP 程式設計的思維只是想去借用數學的理論,例如程式設計中,沒有副作用的純函數就是去參考數學的「函數」,每一個輸入值,都只會對應到一個確切的輸出值,有了純函數的前提,我們又可以進一步去借鏡這些數學的代數結構,參考結合律、單位元素,讓一切變得可隨意組合和拆解,當程式能安全地隨意組合和拆解,開發上就能有更大的彈性,例如可實踐分層設計、分而治之等模式。

也因此我覺得我們只需大概理解背後的數學理論概念即可,雖然我覺得讀這些數學結構也蠻有趣的...有時間的話還是想深入探索 XD

不過回到程式設計本身,重點是這些數學理論能幫助我們解決什麼問題、如何確保我們複雜的程式開發是穩定可預測的。接下來的文章會介紹實務開發上,哪些技術和 FP 有關,也許會發現 FP 概念處處可見~

如果對代數結構(Algebraic Data Type)有興趣的,可參考以下這些連結,我覺得都寫得很好!

Reference


上一篇
[Day 24] Applicative Functor (2):定律與應用範例
下一篇
[Day 26] Lazy Evaluation 和 Generator Function
系列文
30 天的 Functional Programming 之旅26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
taiansu
iT邦新手 3 級 ‧ 2025-10-10 00:14:32

這篇超多字,講得很細,來表示一下敬佩之意。 <O>

Monica iT邦新手 3 級 ‧ 2025-10-10 01:32:01 檢舉

感謝大大~! taiansu 大大的FP文章也是之前讀 FP 的參考資源之一🙏

我要留言

立即登入留言